Skip to content

fix(spp_scoring,spp_scoring_programs): scoring validation, eligibility enforcement, and access control#161

Open
emjay0921 wants to merge 8 commits into19.0from
feat/spp-scoring-programs
Open

fix(spp_scoring,spp_scoring_programs): scoring validation, eligibility enforcement, and access control#161
emjay0921 wants to merge 8 commits into19.0from
feat/spp-scoring-programs

Conversation

@emjay0921
Copy link
Copy Markdown
Contributor

Why is this change needed?

Multiple bugs in the scoring and scoring-programs modules were blocking QA testing:

  • Threshold validation only checked the first boundary, ignoring gaps/overlaps in later thresholds
  • Activating empty scoring models failed silently with no descriptive error
  • Ineligible registrants (NON_POOR) could enroll in programs requiring EXTREME_POOR/POOR
  • Enrollment scores were not recorded on membership records
  • Scoring columns were hidden by default in membership list
  • Maximum Score Age = 0 did not force recalculation
  • Scoring viewer role got access errors on program_ids field
  • Officer role could see Activate/Deactivate buttons they shouldn't use

How was the change implemented?

spp_scoring

  • Threshold validation (#835): Check ALL consecutive boundaries for gaps AND overlaps, with improved error messages showing actual max/min values
  • Empty model validation (#836): Improved error messages with model name, bullet points, and actionable text (e.g., "Add at least one indicator in the Indicators tab")
  • Button access control (#845): Activate/Deactivate buttons restricted to group_scoring_manager

spp_programs

  • Pre/post enrollment hooks (#840): _enroll_eligible_registrants() now calls program._pre_enrollment_hook() before enrollment and _post_enrollment_hook() after. Members failing the hook are marked not_eligible

spp_scoring_programs

  • Enrollment score recording (#841): _post_enrollment_hook() now writes enrollment_score and enrollment_classification to the membership record
  • Scoring columns visible (#842): Changed optional="hide" to optional="show" for enrollment/latest score columns
  • Max age = 0 (#843): score_max_age_days=0 now correctly forces recalculation instead of being treated as "no limit"
  • Viewer ACL (#844): Added read-only ACL for spp.program and spp.program.membership to scoring viewer group

New unit tests

N/A — changes are in validation logic, views, and ACLs. Existing tests cover the core paths.

Unit tests executed by the author

Existing spp_scoring and spp_programs tests pass.

How to test manually

  1. Threshold validation: Create scoring model with overlapping or gapped thresholds → Activate should show specific error
  2. Empty model: Create empty scoring model → Activate should show "No indicators/thresholds defined" with tab references
  3. Enrollment blocking: Score registrant as NON_POOR, enroll in program requiring EXTREME_POOR/POOR → should be marked Not Eligible
  4. Enrollment score: Enroll eligible registrant with auto-score enabled → membership shows enrollment score/classification
  5. Max age 0: Set score_max_age_days=0, enroll → fresh score calculated even if recent score exists
  6. Viewer access: Log in as scoring viewer → can browse scoring models without access error
  7. Officer buttons: Log in as scoring officer → Activate/Deactivate buttons hidden

Related links

…laps

Previously only positive gaps were detected. Now also checks for
overlapping thresholds (negative gaps) across ALL consecutive
boundaries, not just the first. Error messages include actual
max/min values for easier debugging.
…dels

Error messages now include model name, bullet points, and actionable
text directing users to the Indicators/Thresholds tabs.
The _enroll_eligible_registrants method wrote state=enrolled directly
without calling pre/post enrollment hooks. This bypassed scoring
eligibility checks from spp_scoring_programs. Now the pre-enrollment
hook runs per member before enrollment, and members that fail (e.g.,
NON_POOR classification when only EXTREME_POOR/POOR are allowed) are
marked not_eligible instead of enrolled.
…r enrollment

The _post_enrollment_hook calculated a score but did not write it to
the membership record. Now writes enrollment_score and
enrollment_classification on the membership after enrollment. Falls
back to latest existing score if auto-scoring is disabled or fails.
…hip list

Changed enrollment_score, enrollment_classification, latest_score,
and latest_classification from optional=hide to optional=show so they
are visible by default when spp_scoring_programs is installed.
…lation

score_max_age_days=0 was treated as 'no limit' (passed None to engine)
instead of 'always recalculate'. Now explicitly passes 0 which the
engine treats as 'skip cache, always recalculate'. Also made the
engine check explicit: max_age_days > 0 to use cache.
Scoring viewer users got an access error on spp.scoring.model.program_ids
because they had no read access to spp.program. Added read-only ACL for
spp.program and spp.program.membership to the scoring viewer group.
…agers

Officers and viewers could see and click Activate/Deactivate buttons
on scoring models even though their ACLs prevented the write from
succeeding. Now the buttons are only visible to scoring managers.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces pre- and post-enrollment hooks within the program manager to handle scoring eligibility and record enrollment scores on membership records. It also enhances scoring model validation to detect both gaps and overlaps in thresholds, restricts scoring model activation to managers, and updates membership views to display scoring results by default. Review feedback highlights performance concerns regarding N+1 database queries and per-member hook execution during bulk enrollment, alongside a potential type error in threshold validation if score fields are null.

Comment on lines +226 to +235
for member in not_enrolled:
try:
program._pre_enrollment_hook(member.partner_id)
except (ValidationError, UserError) as e:
_logger.info(
"Pre-enrollment hook rejected registrant %s: %s",
member.partner_id.id,
str(e),
)
hook_failed |= member
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling the pre-enrollment hook inside a loop for each member can lead to significant performance bottlenecks during bulk enrollment, as each hook execution might involve complex logic or database queries (like scoring). Consider refactoring the hook API to support batch processing of recordsets for better efficiency.

Comment on lines 269 to 274
sorted_thresholds = self.threshold_ids.sorted(key=lambda t: t.min_score)

# Check for gaps between thresholds
# Check all consecutive threshold boundaries for gaps and overlaps
for i, threshold in enumerate(sorted_thresholds[:-1]):
next_threshold = sorted_thresholds[i + 1]
gap = next_threshold.min_score - threshold.max_score
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The sorting and gap calculation will fail with a TypeError if min_score or max_score are None. Since these fields might not be strictly required at the database level, it's safer to handle potential None values or ensure they default to 0.0 during validation.

        sorted_thresholds = self.threshold_ids.sorted(key=lambda t: t.min_score or 0.0)

        # Check all consecutive threshold boundaries for gaps and overlaps
        for i, threshold in enumerate(sorted_thresholds[:-1]):
            next_threshold = sorted_thresholds[i + 1]
            if threshold.max_score is None or next_threshold.min_score is None:
                continue
            gap = next_threshold.min_score - threshold.max_score

Comment on lines +173 to +187
membership = self.env["spp.program.membership"].search(
[
("partner_id", "=", partner.id),
("program_id", "=", self.id),
("state", "=", "enrolled"),
],
limit=1,
)
if membership:
membership.write(
{
"enrollment_score": score_result.score,
"enrollment_classification": score_result.classification_code,
}
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This search and subsequent write are performed inside a loop for each partner being enrolled. This creates an N+1 performance issue, especially during bulk enrollment. Since the caller (ProgramManager._enroll_eligible_registrants) already has the membership record, the hook API should ideally be refactored to accept the membership record directly to avoid redundant database lookups and multiple write calls.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 14, 2026

Codecov Report

❌ Patch coverage is 73.33333% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.33%. Comparing base (aaa9001) to head (ac9138b).

Files with missing lines Patch % Lines
spp_programs/models/managers/program_manager.py 73.33% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             19.0     #161   +/-   ##
=======================================
  Coverage   71.33%   71.33%           
=======================================
  Files         932      932           
  Lines       54975    54980    +5     
=======================================
+ Hits        39217    39221    +4     
- Misses      15758    15759    +1     
Flag Coverage Δ
spp_api_v2 80.10% <ø> (ø)
spp_api_v2_change_request 66.85% <ø> (ø)
spp_api_v2_cycles 71.12% <ø> (ø)
spp_api_v2_data 64.41% <ø> (ø)
spp_api_v2_entitlements 70.19% <ø> (ø)
spp_api_v2_gis 71.52% <ø> (ø)
spp_api_v2_products 66.27% <ø> (ø)
spp_api_v2_service_points 70.94% <ø> (ø)
spp_api_v2_simulation 71.12% <ø> (ø)
spp_api_v2_vocabulary 57.26% <ø> (ø)
spp_audit 72.60% <ø> (ø)
spp_base_common 90.26% <ø> (ø)
spp_case_entitlements 97.61% <ø> (ø)
spp_case_programs 97.14% <ø> (ø)
spp_cel_event 85.11% <ø> (ø)
spp_claim_169 58.11% <ø> (ø)
spp_dci_client_dr 55.87% <ø> (ø)
spp_dci_client_ibr 60.17% <ø> (ø)
spp_programs 62.24% <73.33%> (+<0.01%) ⬆️
spp_security 66.66% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_scoring/models/scoring_engine.py 54.36% <ø> (-0.23%) ⬇️
spp_scoring/models/scoring_model.py 88.33% <ø> (-0.29%) ⬇️
spp_scoring_programs/__manifest__.py 0.00% <ø> (ø)
spp_scoring_programs/models/program.py 36.61% <ø> (+1.48%) ⬆️
spp_programs/models/managers/program_manager.py 77.27% <73.33%> (-1.07%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant